/**
 * @license
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import '../../../styles/shared-styles.js';
import '../gr-rest-api-interface/gr-rest-api-interface.js';
import '../gr-storage/gr-storage.js';
import '../gr-comment/gr-comment.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-comment-thread_html.js';
import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
import {parseDate} from '../../../utils/date-util.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {appContext} from '../../../services/app-context.js';
import {SpecialFilePath} from '../../../constants/constants.js';

const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;

/**
 * @extends PolymerElement
 */
class GrCommentThread extends mixinBehaviors( [
  /**
   * Not used in this element rather other elements tests
   */
  KeyboardShortcutBehavior,
  PathListBehavior,
], GestureEventListeners(
    LegacyElementMixin(
        PolymerElement))) {
  static get template() { return htmlTemplate; }

  static get is() { return 'gr-comment-thread'; }
  /**
   * Fired when the thread should be discarded.
   *
   * @event thread-discard
   */

  /**
   * Fired when a comment in the thread is permanently modified.
   *
   * @event thread-changed
   */

  /**
   * gr-comment-thread exposes the following attributes that allow a
   * diff widget like gr-diff to show the thread in the right location:
   *
   * line-num:
   *     1-based line number or undefined if it refers to the entire file.
   *
   * comment-side:
   *     "left" or "right". These indicate which of the two diffed versions
   *     the comment relates to. In the case of unified diff, the left
   *     version is the one whose line number column is further to the left.
   *
   * range:
   *     The range of text that the comment refers to (start_line,
   *     start_character, end_line, end_character), serialized as JSON. If
   *     set, range's end_line will have the same value as line-num. Line
   *     numbers are 1-based, char numbers are 0-based. The start position
   *     (start_line, start_character) is inclusive, and the end position
   *     (end_line, end_character) is exclusive.
   */
  static get properties() {
    return {
      changeNum: String,
      comments: {
        type: Array,
        value() { return []; },
      },
      /**
       * @type {?{start_line: number, start_character: number, end_line: number,
       *          end_character: number}}
       */
      range: {
        type: Object,
        reflectToAttribute: true,
      },
      keyEventTarget: {
        type: Object,
        value() { return document.body; },
      },
      commentSide: {
        type: String,
        reflectToAttribute: true,
      },
      patchNum: String,
      path: String,
      projectName: {
        type: String,
        observer: '_projectNameChanged',
      },
      hasDraft: {
        type: Boolean,
        notify: true,
        reflectToAttribute: true,
      },
      isOnParent: {
        type: Boolean,
        value: false,
      },
      parentIndex: {
        type: Number,
        value: null,
      },
      rootId: {
        type: String,
        notify: true,
        computed: '_computeRootId(comments.*)',
      },
      /**
       * If this is true, the comment thread also needs to have the change and
       * line properties property set
       */
      showFilePath: {
        type: Boolean,
        value: false,
      },
      /** Necessary only if showFilePath is true or when used with gr-diff */
      lineNum: {
        type: Number,
        reflectToAttribute: true,
      },
      unresolved: {
        type: Boolean,
        notify: true,
        reflectToAttribute: true,
      },
      _showActions: Boolean,
      _lastComment: Object,
      _orderedComments: Array,
      _projectConfig: Object,
      isRobotComment: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },
      showFileName: {
        type: Boolean,
        value: true,
      },
      showPatchset: {
        type: Boolean,
        value: true,
      },
    };
  }

  static get observers() {
    return [
      '_commentsChanged(comments.*)',
    ];
  }

  get keyBindings() {
    return {
      'e shift+e': '_handleEKey',
    };
  }

  constructor() {
    super();
    this.reporting = appContext.reportingService;
  }

  /** @override */
  created() {
    super.created();
    this.addEventListener('comment-update',
        e => this._handleCommentUpdate(e));
  }

  /** @override */
  attached() {
    super.attached();
    this._getLoggedIn().then(loggedIn => {
      this._showActions = loggedIn;
    });
    this._setInitialExpandedState();
  }

  addOrEditDraft(opt_lineNum, opt_range) {
    const lastComment = this.comments[this.comments.length - 1] || {};
    if (lastComment.__draft) {
      const commentEl = this._commentElWithDraftID(
          lastComment.id || lastComment.__draftID);
      commentEl.editing = true;

      // If the comment was collapsed, re-open it to make it clear which
      // actions are available.
      commentEl.collapsed = false;
    } else {
      const range = opt_range ? opt_range :
        lastComment ? lastComment.range : undefined;
      const unresolved = lastComment ? lastComment.unresolved : undefined;
      this.addDraft(opt_lineNum, range, unresolved);
    }
  }

  addDraft(opt_lineNum, opt_range, opt_unresolved) {
    const draft = this._newDraft(opt_lineNum, opt_range);
    draft.__editing = true;
    draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
    this.push('comments', draft);
  }

  fireRemoveSelf() {
    this.dispatchEvent(new CustomEvent('thread-discard',
        {detail: {rootId: this.rootId}, bubbles: false}));
  }

  _getDiffUrlForPath(path) {
    return GerritNav.getUrlForDiffById(this.changeNum, this.projectName, path,
        this.patchNum);
  }

  _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
    return GerritNav.getUrlForDiffById(changeNum,
        projectName, path, patchNum,
        null, this.lineNum);
  }

  _isPatchsetLevelComment(path) {
    return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
  }

  _computeDisplayPath(path) {
    const displayPath = this.computeDisplayPath(path);
    if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
      return `Patchset ${this.patchNum}`;
    }
    return displayPath;
  }

  _computeDisplayLine() {
    if (this.lineNum) return `#${this.lineNum}`;
    // If range is set, then lineNum equals the end line of the range.
    if (!this.lineNum && !this.range) {
      if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
        return '';
      }
      return 'FILE';
    }
    if (this.range) return `#${this.range.end_line}`;
    return '';
  }

  _getLoggedIn() {
    return this.$.restAPI.getLoggedIn();
  }

  _commentsChanged() {
    this._orderedComments = this._sortedComments(this.comments);
    this.updateThreadProperties();
  }

  updateThreadProperties() {
    if (this._orderedComments.length) {
      this._lastComment = this._getLastComment();
      this.unresolved = this._lastComment.unresolved;
      this.hasDraft = this._lastComment.__draft;
      this.isRobotComment = !!(this._lastComment.robot_id);
    }
  }

  _shouldDisableAction(_showActions, _lastComment) {
    return !_showActions || !_lastComment || !!_lastComment.__draft;
  }

  _hideActions(_showActions, _lastComment) {
    return this._shouldDisableAction(_showActions, _lastComment) ||
      !!_lastComment.robot_id;
  }

  _getLastComment() {
    return this._orderedComments[this._orderedComments.length - 1] || {};
  }

  _handleEKey(e) {
    if (this.shouldSuppressKeyboardShortcut(e)) { return; }

    // Don’t preventDefault in this case because it will render the event
    // useless for other handlers (other gr-comment-thread elements).
    if (e.detail.keyboardEvent.shiftKey) {
      this._expandCollapseComments(true);
    } else {
      if (this.modifierPressed(e)) { return; }
      this._expandCollapseComments(false);
    }
  }

  _expandCollapseComments(actionIsCollapse) {
    const comments =
        dom(this.root).querySelectorAll('gr-comment');
    for (const comment of comments) {
      comment.collapsed = actionIsCollapse;
    }
  }

  /**
   * Sets the initial state of the comment thread.
   * Expands the thread if one of the following is true:
   * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
   * thread is unresolved,
   * - it's a robot comment.
   */
  _setInitialExpandedState() {
    if (this._orderedComments) {
      for (let i = 0; i < this._orderedComments.length; i++) {
        const comment = this._orderedComments[i];
        const isRobotComment = !!comment.robot_id;
        // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
        const resolvedThread = !this.unresolved ||
              this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
        if (comment.collapsed === undefined) {
          comment.collapsed = !isRobotComment && resolvedThread;
        }
      }
    }
  }

  _sortedComments(comments) {
    return comments.slice().sort((c1, c2) => {
      const c1Date = c1.__date || parseDate(c1.updated);
      const c2Date = c2.__date || parseDate(c2.updated);
      const dateCompare = c1Date - c2Date;
      // Ensure drafts are at the end. There should only be one but in edge
      // cases could be more. In the unlikely event two drafts are being
      // compared, use the typical date compare.
      if (c2.__draft && !c1.__draft ) { return -1; }
      if (c1.__draft && !c2.__draft ) { return 1; }
      if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
      // If same date, fall back to sorting by id.
      return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
    });
  }

  _createReplyComment(content, opt_isEditing,
      opt_unresolved) {
    this.reporting.recordDraftInteraction();
    const reply = this._newReply(
        this._orderedComments[this._orderedComments.length - 1].id,
        content,
        opt_unresolved);

    // If there is currently a comment in an editing state, add an attribute
    // so that the gr-comment knows not to populate the draft text.
    for (let i = 0; i < this.comments.length; i++) {
      if (this.comments[i].__editing) {
        reply.__otherEditing = true;
        break;
      }
    }

    if (opt_isEditing) {
      reply.__editing = true;
    }

    this.push('comments', reply);

    if (!opt_isEditing) {
      // Allow the reply to render in the dom-repeat.
      this.async(() => {
        const commentEl = this._commentElWithDraftID(reply.__draftID);
        commentEl.save();
      }, 1);
    }
  }

  _isDraft(comment) {
    return !!comment.__draft;
  }

  /**
   * @param {boolean=} opt_quote
   */
  _processCommentReply(opt_quote) {
    const comment = this._lastComment;
    let quoteStr;
    if (opt_quote) {
      const msg = comment.message;
      quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
    }
    this._createReplyComment(quoteStr, true, comment.unresolved);
  }

  _handleCommentReply() {
    this._processCommentReply();
  }

  _handleCommentQuote() {
    this._processCommentReply(true);
  }

  _handleCommentAck() {
    this._createReplyComment('Ack', false, false);
  }

  _handleCommentDone() {
    this._createReplyComment('Done', false, false);
  }

  _handleCommentFix(e) {
    const comment = e.detail.comment;
    const msg = comment.message;
    const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
    const response = quoteStr + 'Please fix.';
    this._createReplyComment(response, false, true);
  }

  _commentElWithDraftID(id) {
    const els = dom(this.root).querySelectorAll('gr-comment');
    for (const el of els) {
      if (el.comment.id === id || el.comment.__draftID === id) {
        return el;
      }
    }
    return null;
  }

  _newReply(inReplyTo, opt_message, opt_unresolved) {
    const d = this._newDraft();
    d.in_reply_to = inReplyTo;
    if (opt_message != null) {
      d.message = opt_message;
    }
    if (opt_unresolved !== undefined) {
      d.unresolved = opt_unresolved;
    }
    return d;
  }

  /**
   * @param {number=} opt_lineNum
   * @param {!Object=} opt_range
   */
  _newDraft(opt_lineNum, opt_range) {
    const d = {
      __draft: true,
      __draftID: Math.random().toString(36),
      __date: new Date(),
    };

    // For replies, always use same meta info as root.
    if (this.comments && this.comments.length >= 1) {
      const rootComment = this.comments[0];
      [
        'path',
        'patchNum',
        'side',
        '__commentSide',
        'line',
        'range',
        'parent',
      ].forEach(key => {
        if (rootComment.hasOwnProperty(key)) {
          d[key] = rootComment[key];
        }
      });
    } else {
      // Set meta info for root comment.
      d.path = this.path;
      d.patchNum = this.patchNum;
      d.side = this._getSide(this.isOnParent);
      d.__commentSide = this.commentSide;

      if (opt_lineNum) {
        d.line = opt_lineNum;
      }
      if (opt_range) {
        d.range = opt_range;
      }
      if (this.parentIndex) {
        d.parent = this.parentIndex;
      }
    }
    return d;
  }

  _getSide(isOnParent) {
    if (isOnParent) { return 'PARENT'; }
    return 'REVISION';
  }

  _computeRootId(comments) {
    // Keep the root ID even if the comment was removed, so that notification
    // to sync will know which thread to remove.
    if (!comments.base.length) { return this.rootId; }
    const rootComment = comments.base[0];
    return rootComment.id || rootComment.__draftID;
  }

  _handleCommentDiscard(e) {
    const diffCommentEl = dom(e).rootTarget;
    const comment = diffCommentEl.comment;
    const idx = this._indexOf(comment, this.comments);
    if (idx == -1) {
      throw Error('Cannot find comment ' +
          JSON.stringify(diffCommentEl.comment));
    }
    this.splice('comments', idx, 1);
    if (this.comments.length === 0) {
      this.fireRemoveSelf();
    }
    this._handleCommentSavedOrDiscarded(e);

    // Check to see if there are any other open comments getting edited and
    // set the local storage value to its message value.
    for (const changeComment of this.comments) {
      if (changeComment.__editing) {
        const commentLocation = {
          changeNum: this.changeNum,
          patchNum: this.patchNum,
          path: changeComment.path,
          line: changeComment.line,
        };
        return this.$.storage.setDraftComment(commentLocation,
            changeComment.message);
      }
    }
  }

  _handleCommentSavedOrDiscarded(e) {
    this.dispatchEvent(new CustomEvent('thread-changed',
        {detail: {rootId: this.rootId, path: this.path},
          bubbles: false}));
  }

  _handleCommentUpdate(e) {
    const comment = e.detail.comment;
    const index = this._indexOf(comment, this.comments);
    if (index === -1) {
      // This should never happen: comment belongs to another thread.
      console.warn('Comment update for another comment thread.');
      return;
    }
    this.set(['comments', index], comment);
    // Because of the way we pass these comment objects around by-ref, in
    // combination with the fact that Polymer does dirty checking in
    // observers, the this.set() call above will not cause a thread update in
    // some situations.
    this.updateThreadProperties();
  }

  _indexOf(comment, arr) {
    for (let i = 0; i < arr.length; i++) {
      const c = arr[i];
      if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
          (c.id != null && c.id == comment.id)) {
        return i;
      }
    }
    return -1;
  }

  _computeHostClass(unresolved) {
    if (this.isRobotComment) {
      return 'robotComment';
    }
    return unresolved ? 'unresolved' : '';
  }

  /**
   * Load the project config when a project name has been provided.
   *
   * @param {string} name The project name.
   */
  _projectNameChanged(name) {
    if (!name) { return; }
    this.$.restAPI.getProjectConfig(name).then(config => {
      this._projectConfig = config;
    });
  }
}

customElements.define(GrCommentThread.is, GrCommentThread);
